14 WebSocket 客户端进阶与事件机制

WebSocket 客户端进阶与事件机制

关联:索引

要解决的问题

章节内容(本讲核心):

与前置知识衔接(避免重复):

  1. 未就绪发送:连接仍在 CONNECTING 就调用 send
  2. 错误未捕获:onerror 未处理、用户无提示
  3. 断线无提示:onclose 未处理、状态展示缺失
  4. 消息无格式:难以扩展与排错,日志不可读

1. 生命周期事件职责

2. readyState 状态机

3. 统一 JSON 消息格式(Envelope)

推荐最小字段:

{
  "type": "chat|ping|pong|system|error",
  "id": "uuid-or-random",
  "ts": 1710000000000,
  "payload": {},
  "meta": { "from": "client|server", "traceId": "" }
}

4. 心跳机制(概念)

文件路径:client/websocket-demo/src/utils/ws.ts

// 消息类型:限定可用字符串,避免写错(例如把 'ping' 写成 'pign')
export type MsgType = 'chat' | 'ping' | 'pong' | 'system' | 'error'

// 统一消息 Envelope:所有消息都用同一层结构包起来,便于扩展与排错
// T:payload 的具体结构(不同 type 对应不同 payload)
export type Message<T = unknown> = {
  // 消息类型(决定如何处理:聊天/心跳/系统/错误等)
  type: MsgType
  // 消息唯一 id(用于日志追踪、去重、定位某次交互)
  id: string
  // 时间戳(毫秒)
  ts: number
  // 业务载荷(真实数据内容)
  payload: T
  // 元信息(可选):标记来源/链路追踪等
  meta?: { from?: 'client' | 'server'; traceId?: string }
}

// 构造统一消息:避免每次手写 id/ts/meta,保证格式一致
export function buildMessage<T>(type: MsgType, payload: T): Message<T> {
  // randomUUID:现代浏览器可用;可选链避免在不支持时直接报错
  // globalThis:比 window 更通用(浏览器/Node 都可用)
  const uuid = globalThis.crypto?.randomUUID?.()
  return {
    type,
    // 优先使用 uuid;否则回退用随机字符串(课堂 demo 足够)
    id: uuid ?? Math.random().toString(36).slice(2),
    ts: Date.now(),
    payload,
    meta: { from: 'client' }
  }
}

// 将 WebSocket.readyState(数字)映射为可读文本,便于 UI 展示与状态判断
// readyState:0 CONNECTING, 1 OPEN, 2 CLOSING, 3 CLOSED
export type ReadyStateText = 'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED'

export function mapReadyState(ws?: WebSocket): ReadyStateText {
  // ws 可能为 undefined(尚未创建/已释放),用可选链读取
  const s = ws?.readyState
  // 未创建 ws 时 s 为 undefined,这里统一归为 CLOSED(UI 更好处理)
  return s === WebSocket.CONNECTING
    ? 'CONNECTING'
    : s === WebSocket.OPEN
    ? 'OPEN'
    : s === WebSocket.CLOSING
    ? 'CLOSING'
    : 'CLOSED'
}

文件路径:client/websocket-demo/src/components/WebSocketAdvanced.vue

<template>
  <div>
    <h3>WebSocket 客户端进阶</h3>

    <!-- 状态展示:由 ws.readyState 映射而来(CONNECTING/OPEN/CLOSING/CLOSED) -->
    <div>状态:{{ status }}</div>

    <!-- v-model 把输入框内容绑定到 msg(响应式) -->
    <input v-model="msg" type="text" placeholder="输入消息" />

    <!-- 点击触发 send:内部会做“是否 OPEN”判断,避免未就绪发送 -->
    <button @click="send">发送</button>

    <!-- 日志区域:white-space: pre-line 让 \n 换行在页面上生效 -->
    <div style="margin-top: 12px; white-space: pre-line;">{{ logs }}</div>
  </div>
</template>

<script setup lang="ts">
/**
 * 这一段组件做了几件事:
 * 1) 建立 WebSocket 连接,并维护连接状态 status
 * 2) 发送消息时统一包装为 JSON Envelope(buildMessage)
 * 3) 接收消息时做“解析 + 校验”(parseMessage),解析失败则降级显示原始文本
 * 4) 简单心跳:定时发送 ping(服务端可回 pong)
 */

import { ref, onMounted, onBeforeUnmount } from 'vue'
import { buildMessage, mapReadyState } from '../utils/ws'
import type { Message, ReadyStateText } from '../utils/ws'

/**
 * status:展示连接状态(字符串),由 mapReadyState 统一映射。
 * 这里用 ReadyStateText 类型约束取值范围,避免写错状态字符串。
 */
const status = ref<ReadyStateText>('CONNECTING')

/**
 * logs:把所有关键事件(连接成功/收到消息/错误/关闭)累积输出,便于课堂观察。
 */
const logs = ref('')

/**
 * msg:输入框内容(待发送文本)。
 */
const msg = ref('')

/**
 * ws:WebSocket 实例。
 * - onMounted 创建连接
 * - onBeforeUnmount 关闭连接
 */
let ws: WebSocket | null = null

/**
 * heartbeat:心跳定时器句柄,用于组件卸载时清理。
 */
let heartbeat: number | null = null

/**
 * log:向 logs 追加一行文本(带换行)。
 */
function log(s: string) {
  logs.value += s + '\\n'
}

/**
 * setStatus:刷新状态显示。
 * ws 可能为 null,因此用 ws ?? undefined 传给 mapReadyState(参数是可选的)。
 */
function setStatus() {
  status.value = mapReadyState(ws ?? undefined)
}

/**
 * send:发送聊天消息(type=chat)。
 * 关键点:
 * - trim 后为空直接返回
 * - 只允许在 ws.readyState === OPEN 时发送,避免 CONNECTING/CLOSED 时 send 抛异常
 * - 统一用 buildMessage 包装成 JSON Envelope
 */
function send() {
  const text = msg.value.trim()
  if (!text) return

  if (!ws || ws.readyState !== WebSocket.OPEN) {
    alert('连接未建立')
    return
  }

  const m = buildMessage('chat', { text })
  ws.send(JSON.stringify(m))

  log(`发送:${text}`)
  msg.value = ''
}

/**
 * parseMessage:把 unknown 数据“安全地”解析为 Message(Envelope)。
 * 这是一个很典型的“运行时校验”函数:
 * - 因为 JSON.parse 的结果类型是 unknown/any,不能直接当成 Message 用
 * - 先验证:必须是对象、包含 type/id/ts
 * - 再验证:type 必须属于允许集合,id/ts 的类型必须正确
 * 校验通过才返回 Message,否则返回 null(表示“不是我们约定的协议消息”)。
 */
function parseMessage(data: unknown): Message | null {
  if (typeof data !== 'object' || data === null) return null
  if (!('type' in data)) return null

  const type = (data as { type?: unknown }).type
  const id = (data as { id?: unknown }).id
  const ts = (data as { ts?: unknown }).ts
  const payload = (data as { payload?: unknown }).payload

  if (
    type !== 'chat' &&
    type !== 'ping' &&
    type !== 'pong' &&
    type !== 'system' &&
    type !== 'error'
  ) {
    return null
  }

  if (typeof id !== 'string') return null
  if (typeof ts !== 'number') return null

  return { type, id, ts, payload }
}

/**
 * onMounted:组件挂载后建立连接,并绑定 WebSocket 事件。
 */
onMounted(() => {
  // 建立连接(课堂 demo 默认 localhost:8000)
  ws = new WebSocket('ws://localhost:8000/ws')
  setStatus()

  // open:连接建立成功
  ws.onopen = () => {
    setStatus()
    log('连接成功')
  }

  // message:收到服务端消息
  ws.onmessage = (e) => {
    /**
     * e.data 可能是 string / Blob / ArrayBuffer 等。
     * 这里仅对 string 做 JSON.parse;非 string 直接走降级显示。
     */
    let parsed: unknown = e.data
    if (typeof e.data === 'string') {
      try {
        parsed = JSON.parse(e.data) as unknown
      } catch {}
    }

    /**
     * 先按“协议消息”解析:
     * - 成功:按 type 分支处理(pong / error / 其他)
     * - 失败:降级输出原始内容(String(e.data))
     */
    const m = parseMessage(parsed)
    if (m) {
      if (m.type === 'pong') log('心跳回复:pong')
      else if (m.type === 'error') log(`服务端错误:${JSON.stringify(m.payload)}`)
      else log(`服务端:${JSON.stringify(m.payload)}`)
      return
    }

    // 降级策略:无法识别为 Envelope,就直接展示原始内容
    log(`服务端:${String(e.data)}`)
  }

  // error:连接或收发发生错误(通常随后会 close)
  ws.onerror = () => {
    log('连接错误')
    setStatus()
  }

  // close:连接已关闭(服务端断开/网络变化/客户端主动 close)
  ws.onclose = () => {
    log('连接关闭')
    setStatus()
  }

  /**
   * 心跳:每 10s 发送一次 ping
   * - 仅 OPEN 才发送,避免非法状态 send
   * - 服务端若支持,会回 pong;客户端据此记录“连接仍活着”
   */
  heartbeat = window.setInterval(() => {
    if (ws && ws.readyState === WebSocket.OPEN) {
      const ping = buildMessage('ping', {})
      ws.send(JSON.stringify(ping))
    }
  }, 10000)
})

/**
 * onBeforeUnmount:组件卸载前清理资源
 * - 清理心跳定时器
 * - 如果连接仍处于 OPEN,则主动关闭(释放资源)
 */
onBeforeUnmount(() => {
  if (heartbeat) {
    clearInterval(heartbeat)
  }
  if (ws && ws.readyState === WebSocket.OPEN) ws.close()
})
</script>

入口文件与页面可复用上一课的 main.ts 与 index.html,将组件替换为 WebSocketAdvanced.vue。
入口文件路径:client/websocket-demo/src/main.ts
页面文件路径:client/websocket-demo/index.html

可选:服务端最简 pong 分支(FastAPI)

文件路径:server/app.py

from fastapi import FastAPI, WebSocket
import uvicorn, json, time

app = FastAPI()

@app.websocket("/ws")
async def ws_endpoint(websocket: WebSocket):
    # 1) 接受握手:没有 accept() 就无法开始收发消息
    await websocket.accept()
    try:
        while True:
            # 2) 等待客户端发来文本消息
            text = await websocket.receive_text()
            try:
                # 3) 优先按 JSON 协议解析
                data = json.loads(text)
            except:
                # 4) 解析失败则降级为“文本回显”(兼容未上协议格式的客户端)
                await websocket.send_text(text)
                continue
            t = data.get("type")
            if t == "ping":
                # 5) 心跳:收到 ping 回复 pong(最简可行)
                await websocket.send_text(json.dumps({"type":"pong","ts":int(time.time()*1000),"payload":{}}))
            elif t == "chat":
                # 6) chat:示例做 echo,把 payload 原样回去(或包装成 echo 字段)
                await websocket.send_text(json.dumps({"type":"chat","payload":{"echo":data.get("payload")}}))
            else:
                # 7) 其他 type:给一个 system 响应,避免客户端“没回包”
                await websocket.send_text(json.dumps({"type":"system","payload":{"info":"ok"}}))
    except Exception:
        # 课堂 demo 简化:异常直接结束循环(生产环境应区分断开与错误并记录日志)
        pass

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)
测试点 操作与验证 预期结果
连接建立 启动服务端,打开客户端 显示“✅ 连接成功”,status=OPEN
未就绪发送 在 CONNECTING 状态点击发送 拦截并弹出提示,不执行 send
消息收发 输入文本并发送 客户端显示发送日志,收到服务端回复并记录
错误处理 关闭服务端后再发送 显示“❌ 连接错误/关闭”,status=CLOSED
心跳行为 保持连接 ≥20s,观察日志 定时发送 ping,收到 pong 或文本降级
关闭清理 关闭页面或主动关闭连接 停止心跳、状态更新、无异常报错

工程化落地补充(可选)